From 8ce84340bf427a65f907bd0e3c1cd907c71860ac Mon Sep 17 00:00:00 2001 From: Andrew Garrett Date: Tue, 8 Sep 2009 15:02:41 +0000 Subject: [PATCH] Add AJAX category management system. Includes suggestion system, dialogs for setting edit summaries and confirming, and methods for adding and removing all categories, but only directly in the page text (as categories in templates cannot be removed). --- includes/AutoLoader.php | 1 + includes/DefaultSettings.php | 5 + includes/OutputPage.php | 5 + includes/Skin.php | 2 + includes/Xml.php | 7 +- js2/ajaxcategories.js | 315 ++++++++++++ .../jquery/plugins/jquery.suggestions.js | 459 ++++++++++++++++++ js2/mwEmbed/mv_embed.js | 1 + languages/messages/MessagesEn.php | 14 + skins/common/images/add.png | Bin 0 -> 3329 bytes skins/common/images/remove.png | Bin 0 -> 3346 bytes skins/common/shared.css | 24 + 12 files changed, 831 insertions(+), 2 deletions(-) create mode 100644 js2/ajaxcategories.js create mode 100644 js2/mwEmbed/jquery/plugins/jquery.suggestions.js create mode 100755 skins/common/images/add.png create mode 100755 skins/common/images/remove.png diff --git a/includes/AutoLoader.php b/includes/AutoLoader.php index 681eb95857..cae8839ec5 100644 --- a/includes/AutoLoader.php +++ b/includes/AutoLoader.php @@ -619,6 +619,7 @@ $wgJSAutoloadLocalClasses = array( // phase 2 javascript: 'uploadPage' => 'js2/uploadPage.js', 'editPage' => 'js2/editPage.js', + 'ajaxCategories' => 'js2/ajaxcategories.js', ); //Include the js2 autoLoadClasses diff --git a/includes/DefaultSettings.php b/includes/DefaultSettings.php index 698ec27d94..0ef9de4a94 100644 --- a/includes/DefaultSettings.php +++ b/includes/DefaultSettings.php @@ -4195,3 +4195,8 @@ $wgCrossSiteAJAXdomainExceptions = array(); * The minimum amount of memory that MediaWiki "needs"; MediaWiki will try to raise PHP's memory limit if it's below this amount. */ $wgMemoryLimit = "50M"; + +/** + * Whether or not to use the AJAX categories system. + */ +$wgUseAJAXCategories = false; diff --git a/includes/OutputPage.php b/includes/OutputPage.php index 3adb63bb92..cbbd3a8aca 100644 --- a/includes/OutputPage.php +++ b/includes/OutputPage.php @@ -1115,6 +1115,11 @@ class OutputPage { if( $wgUser->getBoolOption( 'editsectiononrightclick' ) ) { $this->addScriptFile( 'rightclickedit.js' ); } + + global $wgUseAJAXCategories; + if ($wgUseAJAXCategories) { + $this->addScriptClass( 'ajaxCategories' ); + } if( $wgUniversalEditButton ) { if( isset( $wgArticle ) && $this->getTitle() && $this->getTitle()->quickUserCan( 'edit' ) diff --git a/includes/Skin.php b/includes/Skin.php index 9118db939c..37392b6a28 100644 --- a/includes/Skin.php +++ b/includes/Skin.php @@ -409,6 +409,8 @@ class Skin extends Linker { 'wgSeparatorTransformTable' => $compactSeparatorTransTable, 'wgDigitTransformTable' => $compactDigitTransTable, 'wgMainPageTitle' => $mainPage ? $mainPage->getPrefixedText() : null, + 'wgFormattedNamespaces' => $wgContLang->getFormattedNamespaces(), + 'wgNamespaceIds' => $wgContLang->getNamespaceIds(), ); if ( $wgContLang->hasVariants() ) { $vars['wgUserVariant'] = $wgContLang->getPreferredVariant(); diff --git a/includes/Xml.php b/includes/Xml.php index 1540b95221..4929a30d8f 100644 --- a/includes/Xml.php +++ b/includes/Xml.php @@ -567,7 +567,9 @@ class Xml { $s = 'null'; } elseif ( is_int( $value ) ) { $s = $value; - } elseif ( is_array( $value ) ) { + } elseif ( is_array( $value ) && // Make sure it's not associative. + array_keys($value) === range(0,count($value)-1) + ) { $s = '['; foreach ( $value as $elt ) { if ( $s != '[' ) { @@ -576,7 +578,8 @@ class Xml { $s .= self::encodeJsVar( $elt ); } $s .= ']'; - } elseif ( is_object( $value ) ) { + } elseif ( is_object( $value ) || is_array( $value ) ) { + // Objects and associative arrays $s = '{'; foreach ( (array)$value as $name => $elt ) { if ( $s != '{' ) { diff --git a/js2/ajaxcategories.js b/js2/ajaxcategories.js new file mode 100644 index 0000000000..8b204d7112 --- /dev/null +++ b/js2/ajaxcategories.js @@ -0,0 +1,315 @@ +loadGM( { + "ajax-add-category":"[Add Category]", + "ajax-add-category-submit":"[Add]", + "ajax-confirm-prompt":"[Confirmation Text]", + "ajax-confirm-title":"[Confirmation Title]", + "ajax-confirm-save":"[Save]", + "ajax-add-category-summary":"[Add category $1]", + "ajax-remove-category-summary":"[Remove category $2]", + "ajax-confirm-actionsummary":"[Summary]", + "ajax-error-title":"Error", + "ajax-error-dismiss":"OK", + "ajax-remove-category-error":"[RemoveErr]" + } ); + +var ajaxCategories = { + + handleAddLink : function(e) { + e.preventDefault(); + + // Make sure the suggestion plugin is loaded. Load everything else while we're at it + mvJsLoader.doLoad( ['$j.ui', '$j.ui.dialog', '$j.fn.suggestions'], + function() { + $j('#mw-addcategory-prompt').toggle(); + + $j('#mw-addcategory-input').suggestions( { + 'fetch':ajaxCategories.fetchSuggestions, + 'cancel': function() { + var req = ajaxCategories.request; + if (req.abort) + req.abort() + }, + } ); + + $j('#mw-addcategory-input').suggestions(); + } ); + }, + + fetchSuggestions : function( query ) { + var that = this; + var request = $j.ajax( { + url: wgScriptPath + '/api.php', + data: { + 'action': 'query', + 'list': 'allpages', + 'apnamespace': 14, + 'apprefix': $j(this).val(), + 'format': 'json' + }, + dataType: 'json', + success: function( data ) { + // Process data.query.allpages into an array of titles + var pages = data.query.allpages; + var titleArr = []; + + $j.each(pages, function(i, page) { + var title = page.title.split( ':', 2 )[1]; + titleArr.push(title); + } ); + + $j(that).suggestions( 'suggestions', titleArr ); + } + }); + + ajaxCategories.request = request; + }, + + reloadCategoryList : function( response ) { + var holder = $j('
'); + + holder.load( window.location.href+' .catlinks', function() { + $j('.catlinks').replaceWith( holder.find('.catlinks') ); + ajaxCategories.setupAJAXCategories(); + ajaxCategories.removeProgressIndicator( $j('.catlinks') ); + }); + }, + + confirmEdit : function( page, fn, actionSummary, doneFn ) { + // Load jQuery UI + mvJsLoader.doLoad( ['$j.ui', '$j.ui.dialog', '$j.suggestions'], function() { + // Produce a confirmation dialog + + var dialog = $j('
'); + + dialog.addClass('mw-ajax-confirm-dialog'); + dialog.attr( 'title', gM('ajax-confirm-title') ); + + // Intro text. + var confirmIntro = $j('

'); + confirmIntro.text( gM('ajax-confirm-prompt') ); + dialog.append(confirmIntro); + + // Summary of the action to be taken + var summaryHolder = $j('

'); + var summaryLabel = $j(''); + summaryLabel.text(gM('ajax-confirm-actionsummary')+" " ); + summaryHolder.text( actionSummary ); + summaryHolder.prepend( summaryLabel ); + dialog.append(summaryHolder); + + // Reason textbox. + var reasonBox = $j(''); + reasonBox.addClass('mw-ajax-confirm-reason'); + dialog.append(reasonBox); + + // Submit button + var submitButton = $j(''); + submitButton.val( gM( 'ajax-confirm-save' ) ); + + var submitFunction = function() { + ajaxCategories.addProgressIndicator( dialog ); + ajaxCategories.doEdit( page, fn, reasonBox.val(), + function() { + doneFn(); + dialog.dialog('close'); + ajaxCategories.removeProgressIndicator( dialog ); + } + ); + }; + + var buttons = {}; + buttons[gM('ajax-confirm-save')] = submitFunction; + var dialogOptions = { + 'AutoOpen' : true, + 'buttons' : buttons, + 'width' : 450, + }; + + $j('#catlinks').prepend(dialog); + dialog.dialog( dialogOptions ); + } ); + }, + + doEdit : function( page, fn, summary, doneFn ) { + // Get an edit token for the page. + var getTokenVars = { + 'action':'query', + 'prop':'info|revisions', + 'intoken':'edit', + 'titles':page, + 'rvprop':'content|timestamp', + 'format':'json', + }; + $j.get(wgScriptPath+'/api.php', getTokenVars, + function( reply ) { + var infos = reply.query.pages; + $j.each(infos, function(pageid, data) { + var token = data.edittoken; + var timestamp = data.revisions[0].timestamp; + var oldText = data.revisions[0]['*']; + + var newText = fn(oldText); + + if (newText === false) return; + + var postEditVars = { + 'action':'edit', + 'title':page, + 'text':newText, + 'summary':summary, + 'token':token, + 'basetimestamp':timestamp, + 'format':'json', + }; + + $j.post( wgScriptPath+'/api.php', postEditVars, doneFn, 'json' ); + } ); + } + , 'json' ); + }, + + addProgressIndicator : function( elem ) { + var indicator = $j('

'); + + indicator.addClass('mw-ajax-loader'); + + elem.append( indicator ); + }, + + removeProgressIndicator : function( elem ) { + elem.find('.mw-ajax-loader').remove(); + }, + + handleCategoryAdd : function(e) { + // Grab category text + var category = $j('#mw-addcategory-input').val(); + var appendText = "\n[["+wgFormattedNamespaces[14]+":"+category+"]]\n"; + var summary = gM('ajax-add-category-summary', category); + + ajaxCategories.confirmEdit( wgPageName, function(oldText) { return oldText+appendText }, + summary, ajaxCategories.reloadCategoryList ); + }, + + handleDeleteLink : function(e) { + e.preventDefault(); + + var category = $j(this).parent().find('a').text(); + + // Build a regex that matches legal invocations of that category. + + // In theory I should escape the aliases, but there's no JS function for it + // Shouldn't have any real impact, can't be exploited or anything, so we'll + // leave it for now. + var categoryNSFragment = ''; + $j.each(wgNamespaceIds, function( name, id ) { + if (id == 14) { + // Allow the first character to be any case + var firstChar = name.charAt(0); + firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']' + categoryNSFragment += '|'+firstChar+name.substr(1); + } + } ); + categoryNSFragment = categoryNSFragment.substr(1) // Remove leading | + + + // Build the regex + var titleFragment = category; + + firstChar = category.charAt(0); + firstChar = '['+firstChar.toUpperCase()+firstChar.toLowerCase()+']'; + titleFragment = firstChar+category.substr(1); + var categoryRegex = '\\[\\['+categoryNSFragment+':'+titleFragment+'(\\|[^\\]]*)?\\]\\]'; + categoryRegex = new RegExp( categoryRegex, 'g' ); + + var summary = gM('ajax-remove-category-summary', category); + + ajaxCategories.confirmEdit( wgPageName, + function(oldText) { + var newText = oldText.replace(categoryRegex, ''); + + if (newText == oldText) { + var error = gM('ajax-remove-category-error'); + ajaxCategories.showError( error ); + ajaxCategories.removeProgressIndicator( $j('.mw-ajax-confirm-dialog') ); + $j('.mw-ajax-confirm-dialog').dialog('close'); + return false; + } + + return newText; + }, summary, ajaxCategories.reloadCategoryList ); + }, + + showError : function( str ) { + var dialog = $j('
'); + dialog.text(str); + + $j('#bodyContent').append(dialog); + + var buttons = {}; + buttons[gM('ajax-error-dismiss')] = function(e) { dialog.dialog('close'); }; + var dialogOptions = { + 'buttons' : buttons, + 'AutoOpen' : true, + 'title' : gM('ajax-error-title'), + }; + + dialog.dialog(dialogOptions); + }, + + setupAJAXCategories : function() { + var clElement = $j('.catlinks'); + + // Unhide hidden category holders. + clElement.removeClass( 'catlinks-allhidden' ); + + var addLink = $j(''); + addLink.addClass( 'mw-ajax-addcategory' ); + + // Create [Add Category] link + addLink.text( gM( 'ajax-add-category' ) ); + addLink.attr('href', '#'); + addLink.click( ajaxCategories.handleAddLink ); + clElement.append(addLink); + + // Create add category prompt + var promptContainer = $j('
'); + var promptTextbox = $j(''); + var addButton = $j('' ); + addButton.val( gM('ajax-add-category-submit') ); + + promptTextbox.keypress( ajaxCategories.handleCategoryInput ); + addButton.click( ajaxCategories.handleCategoryAdd ); + + promptContainer.append(promptTextbox); + promptContainer.append(addButton); + promptContainer.hide(); + + // Create delete link for each category. + $j('.catlinks div span a').each( function(e) { + // Create a remove link + var deleteLink = $j(''); + + deleteLink.click(ajaxCategories.handleDeleteLink); + + $j(this).after(deleteLink); + } ); + + clElement.append(promptContainer); + }, + +}; + +js2AddOnloadHook( ajaxCategories.setupAJAXCategories ); +loadGM( { + "ajax-add-category":"[Add Category]", + "ajax-add-category-submit":"[Add]", + "ajax-confirm-prompt":"[Confirmation Text]", + "ajax-confirm-title":"[Confirmation Title]", + "ajax-confirm-save":"[Save]", + "ajax-add-category-summary":"[Add category $1]", + "ajax-remove-category-summary":"[Remove category $2]", + "ajax-confirm-actionsummary":"[Summary]", + "ajax-error-title":"Error", + "ajax-error-dismiss":"OK", + "ajax-remove-category-error":"[RemoveErr]" + } ); diff --git a/js2/mwEmbed/jquery/plugins/jquery.suggestions.js b/js2/mwEmbed/jquery/plugins/jquery.suggestions.js new file mode 100644 index 0000000000..489f52fbe3 --- /dev/null +++ b/js2/mwEmbed/jquery/plugins/jquery.suggestions.js @@ -0,0 +1,459 @@ +/** + * This plugin provides a generic way to add suggestions to a text box + * Usage: + * + * Set options + * $('#textbox').suggestions({ option1: value1, option2: value2 }); + * $('#textbox').suggestions( option, value ); + * Get option: + * value = $('#textbox').suggestions( option ); + * Initialize: + * $('#textbox').suggestions(); + * + * Available options: + * animationDuration: How long (in ms) the animated growing of the results box + * should take (default: 200) + * cancelPending(): Function called when any pending asynchronous suggestions + * fetches should be canceled (optional). Executed in the context of the + * textbox + * delay: Number of ms to wait for the user to stop typing (default: 120) + * fetch(query): Callback that should fetch suggestions and set the suggestions + * property (required). Executed in the context of the textbox + * maxGrowFactor: Maximum width of the suggestions box as a factor of the width + * of the textbox (default: 2) + * maxRows: Maximum number of suggestion rows to show + * submitOnClick: If true, submit the form when a suggestion is clicked + * (default: false) + * suggestions: Array of suggestions to display (default: []) + * + */ +(function($) { +$.fn.suggestions = function( param, param2 ) { + /** + * Handle special keypresses (arrow keys and escape) + * @param key Key code + */ + function processKey( key ) { + switch ( key ) { + case 40: + // Arrow down + if ( conf._data.div.is( ':visible' ) ) { + highlightResult( 'next', true ); + } else { + // Load suggestions right now + updateSuggestions( false ); + } + break; + case 38: + // Arrow up + if ( conf._data.div.is( ':visible' ) ) { + highlightResult( 'prev', true ); + } + break; + case 27: + // Escape + conf._data.div.hide(); + restoreText(); + cancelPendingSuggestions(); + break; + default: + updateSuggestions( true ); + } + } + + /** + * Restore the text the user originally typed in the textbox, + * before it was overwritten by highlightResult(). This restores the + * value the currently displayed suggestions are based on, rather than + * the value just before highlightResult() overwrote it; the former + * is arguably slightly more sensible. + */ + function restoreText() { + conf._data.textbox.val( conf._data.prevText ); + } + + /** + * Ask the user-specified callback for new suggestions. Any previous + * delayed call to this function still pending will be canceled. + * If the value in the textbox hasn't changed since the last time + * suggestions were fetched, this function does nothing. + * @param delayed If true, delay this by the user-specified delay + */ + function updateSuggestions( delayed ) { + // Cancel previous call + if ( conf._data.timerID != null ) + clearTimeout( conf._data.timerID ); + if ( delayed ) + setTimeout( doUpdateSuggestions, conf.delay ); + else + doUpdateSuggestions(); + } + + /** + * Delayed part of updateSuggestions() + * Don't call this, use updateSuggestions( false ) instead + */ + function doUpdateSuggestions() { + if ( conf._data.textbox.val() == conf._data.prevText ) + // Value in textbox didn't change + return; + + conf._data.prevText = conf._data.textbox.val(); + conf.fetch.call ( conf._data.textbox, + conf._data.textbox.val() ); + } + + /** + * Called when the user changes the suggestions post-init. + * Typically happens asynchronously from conf.fetch() + */ + function suggestionsChanged() { + conf._data.div.show(); + updateSuggestionsTable(); + fitContainer(); + trimResultText(); + } + + /** + * Cancel any delayed updateSuggestions() call and inform the user so + * they can cancel their result fetching if they use AJAX or something + */ + function cancelPendingSuggestions() { + if ( conf._data.timerID != null ) + clearTimeout( conf._data.timerID ); + conf.cancelPending.call( this ); + } + + /** + * Rebuild the suggestions table + */ + function updateSuggestionsTable() { + // If there are no suggestions, hide the div + if ( conf.suggestions.length == 0 ) { + conf._data.div.hide(); + return; + } + + var table = conf._data.div.children( 'table' ); + table.empty(); + for ( var i = 0; i < conf.suggestions.length; i++ ) { + var td = $( '' ) // FIXME: why use a span? + .append( $( '' ).text( conf.suggestions[i] ) ); + //.addClass( 'os-suggest-result' ); //FIXME: use descendant selector + $( '' ) + .addClass( 'os-suggest-result' ) // FIXME: use descendant selector + .attr( 'rel', i ) + .data( 'text', conf.suggestions[i] ) + .append( td ) + .appendTo( table ); + } + } + + /** + * Make the container fit into the screen + */ + function fitContainer() { + if ( conf._data.div.is( ':hidden' ) ) + return; + + // FIXME: Mysterious -20 from mwsuggest.js, + // presumably to make room for a scrollbar + var availableHeight = $( 'body' ).height() - ( + Math.round( conf._data.div.offset().top ) - + $( document ).scrollTop() ) - 20; + var rowHeight = conf._data.div.find( 'tr' ).outerHeight(); + var numRows = Math.floor( availableHeight / rowHeight ); + + // Show at least 2 rows if there are multiple results + if ( numRows < 2 && conf.suggestions.length >= 2 ) + numRows = 2; + if ( numRows > conf.maxRows ) + numRows = conf.maxRows; + + var tableHeight = conf._data.div.find( 'table' ).outerHeight(); + if ( numRows * rowHeight < tableHeight ) { + // The container is too small + conf._data.div.height( numRows * rowHeight ); + conf._data.visibleResults = numRows; + } else { + // The container is possibly too large + conf._data.div.height( tableHeight ); + conf._data.visibleResults = conf.suggestions.length; + } + } + + /** + * If there are results wider than the container, try to grow the + * container or trim them to end with "..." + */ + function trimResultText() { + if ( conf._data.div.is( ':hidden' ) ) + return; + + // Try to grow the container so all results fit + // Can't use each() here because the inner function can read + // but not write maxWidth for some crazy reason + var maxWidth = 0; + var spans = conf._data.div.find( 'span' ).get(); + for ( var i = 0; i < spans.length; i++ ) + if ( $(spans[i]).outerWidth() > maxWidth ) + maxWidth = $(spans[i]).outerWidth(); + + // FIXME: Some mysterious fixing going on here + // FIXME: Left out Opera fix for now + // FIXME: This doesn't check that the container won't run off the screen + // FIXME: This should try growing to the left instead if no space on the right + var fix = 0; + if ( conf._data.visibleResults < conf.suggestions.length ) + fix = 20; + //else + // fix = operaWidthFix(); + if ( fix < 4 ) + // FIXME: Make 4px configurable? + fix = 4; // Always pad at least 4px + maxWidth += fix; + + var textBoxWidth = conf._data.textbox.outerWidth(); + var factor = maxWidth / textBoxWidth; + if ( factor > conf.maxGrowFactor ) + factor = conf.maxGrowFactor; + if ( factor < 1 ) + // Don't shrink the container to be smaller + // than the textbox + factor = 1; + var newWidth = Math.round( textBoxWidth * factor ); + if ( newWidth != conf._data.div.outerWidth() ) + conf._data.div.animate( { width: newWidth }, + conf.animationDuration ); + // FIXME: mwsuggest.js has this inside the if != block + // but I don't think that's right + newWidth -= fix; + + // If necessary, trim and add ... + conf._data.div.find( 'tr' ).each( function() { + var span = $(this).find( 'span' ); + if ( span.outerWidth() > newWidth ) { + var span = $(this).find( 'span' ); + span.text( span.text() + '...' ); + + // While it's still too wide and the last + // iteration shrunk it, remove the character + // before '...' + while ( span.outerWidth() > newWidth && span.text().length > 3 ) { + span.text( span.text().substring( 0, + span.text().length - 4 ) + '...' ); + } + $(this).attr( 'title', $(this).data( 'text' ) ); + } + }); + } + + /** + * Get a jQuery object for the currently highlighted row + */ + function getHighlightedRow() { + return conf._data.div.find( '.os-suggest-result-hl' ); + } + + /** + * Highlight a result in the results table + * @param result to highlight: jQuery object, or 'prev' or 'next' + * @param updateTextbox If true, put the suggestion in the textbox + */ + function highlightResult( result, updateTextbox ) { + // TODO: Use our own class here + var selected = getHighlightedRow(); + if ( !result.get || selected.get( 0 ) != result.get( 0 ) ) { + if ( result == 'prev' ) { + result = selected.prev(); + } else if ( result == 'next' ) { + if ( selected.size() == 0 ) + // No item selected, go to the first one + result = conf._data.div.find( 'tr:first' ); + else { + result = selected.next(); + if ( result.size() == 0 ) + // We were at the last item, stay there + result = selected; + } + } + + selected.removeClass( 'os-suggest-result-hl' ); + result.addClass( 'os-suggest-result-hl' ); + } + + if ( updateTextbox ) { + if ( result.size() == 0 ) + restoreText(); + else + conf._data.textbox.val( result.data( 'text' ) ); + } + + if ( result.size() > 0 && conf._data.visibleResults < conf.suggestions.length ) { + // Not all suggestions are visible + // Scroll if needed + + // height of a result row + var rowHeight = result.outerHeight(); + // index of first visible element + var first = conf._data.div.scrollTop() / rowHeight; + // index of last visible element + var last = first + conf._data.visibleResults - 1; + // index of element to scroll to + var to = result.attr( 'rel' ); + + if ( to < first ) + // Need to scroll up + conf._data.div.scrollTop( to * rowHeight ); + else if ( result.attr( 'rel' ) > last ) + // Need to scroll down + conf._data.div.scrollTop( ( to - conf._data.visibleResults + 1 ) * rowHeight ); + } + } + + /** + * Initialize the widget + */ + function init() { + if ( typeof conf != 'object' || typeof conf._data != 'undefined' ) + // Configuration not set or init already done + return; + + // Set defaults + if ( typeof conf.animationDuration == 'undefined' ) + conf.animationDuration = 200; + if ( typeof conf.cancelPending != 'function' ) + conf.cancelPending = function() {}; + if ( typeof conf.delay == 'undefined' ) + conf.delay = 250; + if ( typeof conf.maxGrowFactor == 'undefined' ) + conf.maxGrowFactor = 2; + if ( typeof conf.maxRows == 'undefined' ) + conf.maxRows = 7; + if ( typeof conf.submitOnClick == 'undefined' ) + conf.submitOnClick = false; + if ( typeof conf.suggestions != 'object' ) + conf.suggestions = []; + + conf._data = {}; + conf._data.textbox = $(this); + conf._data.timerID = null; // ID of running timer + conf._data.prevText = null; // Text in textbox when suggestions were last fetched + conf._data.visibleResults = 0; // Number of results visible without scrolling + conf._data.mouseDownOn = $( [] ); // Suggestion the last mousedown event occured on + + // Create container div for suggestions + conf._data.div = $( '
' ) + .addClass( 'os-suggest' ) //TODO: use own CSS + .css( { + top: Math.round( $(this).offset().top ) + this.offsetHeight, + left: Math.round( $(this).offset().left ), + width: $(this).outerWidth() + }) + .hide() + .appendTo( $( 'body' ) ); + + // Create results table + $( '' ) + .addClass( 'os-suggest-results' ) // TODO: use descendant selector + .width( $(this).outerWidth() ) // TODO: see if we need Opera width fix + .appendTo( conf._data.div ); + + $(this) + // Stop browser autocomplete from interfering + .attr( 'autocomplete', 'off') + .keydown( function( e ) { + // Store key pressed to handle later + conf._data.keypressed = (e.keyCode == undefined) ? e.which : e.keyCode; + conf._data.keypressed_count = 0; + }) + .keypress( function() { + conf._data.keypressed_count++; + processKey( conf._data.keypressed ); + }) + .keyup( function() { + // Some browsers won't throw keypress() for + // arrow keys. If we got a keydown and a keyup + // without a keypress in between, solve that + if (conf._data.keypressed_count == 0 ) + processKey( conf._data.keypressed ); + }) + .blur( function() { + // When losing focus because of a mousedown + // on a suggestion, don't hide the suggestions + if ( conf._data.mouseDownOn.size() > 0 ) + return; + conf._data.div.hide(); + cancelPendingSuggestions(); + }); + + conf._data.div + .mouseover( function( e ) { + var tr = $( e.target ).closest( '.os-suggest tr' ); + highlightResult( tr, false ); + }) + // Can't use click() because the container div is hidden + // when the textbox loses focus. Instead, listen for a + // mousedown followed by a mouseup on the same + .mousedown( function( e ) { + var tr = $( e.target ).closest( '.os-suggest tr' ); + conf._data.mouseDownOn = tr; + }) + .mouseup( function( e ) { + var tr = $( e.target ).closest( '.os-suggest tr' ); + var other = conf._data.mouseDownOn; + conf._data.mouseDownOn = $( [] ); + if ( tr.get( 0 ) != other.get( 0 ) ) + return; + + highlightResult( tr, true ); + conf._data.div.hide(); + conf._data.textbox.focus(); + if ( conf.submitOnClick ) + conf._data.textbox.closest( 'form' ) + .submit(); + }); + } + + function getProperty( prop ) { + return ( param[0] == '_' ? undefined : conf[param] ); + } + + function setProperty( prop, value ) { + if ( typeof conf == 'undefined' ) { + $(this).data( 'suggestionsConfiguration', {} ); + conf = $(this).data( 'suggestionsConfiguration' ); + } + if ( prop[0] != '_' ) + conf[prop] = value; + if ( prop == 'suggestions' && conf._data ) + // Setting suggestions post-init + suggestionsChanged(); + } + + + // Body of suggestions() starts here + var conf = $(this).data( 'suggestionsConfiguration' ); + if ( typeof param == 'object' ) + return this.each( function() { + // Bulk-set properties + for ( key in param ) { + // Make sure that this in setProperty() + // is set right + setProperty.call( this, key, param[key] ); + } + }); + else if ( typeof param == 'string' ) { + if ( typeof param2 != 'undefined' ) + return this.each( function() { + setProperty( param, param2 ); + }); + else + return getProperty( param ); + } else if ( typeof param != 'undefined' ) + // Incorrect usage, ignore + return this; + + // No parameters given, initialize + return this.each( init ); +};})(jQuery); diff --git a/js2/mwEmbed/mv_embed.js b/js2/mwEmbed/mv_embed.js index ab000c3635..792c859a7b 100644 --- a/js2/mwEmbed/mv_embed.js +++ b/js2/mwEmbed/mv_embed.js @@ -191,6 +191,7 @@ lcPaths({ "$j.secureEvalJSON" : "jquery/plugins/jquery.secureEvalJSON.js", "$j.cookie" : "jquery/plugins/jquery.cookie.js", "$j.contextMenu" : "jquery/plugins/jquery.contextMenu.js", + "$j.fn.suggestions" : "jquery/plugins/jquery.suggestions.js", "$j.effects.blind" : "jquery/jquery.ui/ui/effects.blind.js", "$j.effects.drop" : "jquery/jquery.ui/ui/effects.drop.js", diff --git a/languages/messages/MessagesEn.php b/languages/messages/MessagesEn.php index 160f9eb82b..50bfe04a53 100644 --- a/languages/messages/MessagesEn.php +++ b/languages/messages/MessagesEn.php @@ -4194,4 +4194,18 @@ Enter the filename without the "{{ns:file}}:" prefix.', 'htmlform-reset' => 'Undo changes', 'htmlform-selectorother-other' => 'Other', +'ajax-add-category' => 'Add Category', +'ajax-add-category-submit' => 'Add', +'ajax-confirm-title' => 'Confirm Action', +'ajax-confirm-prompt' => 'Please confirm this action, and enter the reason for it in the +box below. Once you are happy to submit it, click "Save". Note that repeatedly making false +edits will result in your being blocked from Wikipedia.', +'ajax-confirm-save' => 'Save', +'ajax-add-category-summary' => 'Add category "$1"', +'ajax-remove-category-summary' => 'Remove category "$1"', +'ajax-confirm-actionsummary' => 'Action to take:', +'ajax-error-title' => 'Error', +'ajax-error-dismiss' => 'OK', +'ajax-remove-category-error' => 'It was not possible to remove this category. This usually +occurs when the category has been added to the page in a template.', ); diff --git a/skins/common/images/add.png b/skins/common/images/add.png new file mode 100755 index 0000000000000000000000000000000000000000..5b051f647fecd43ea4ed665486734acb38eaa952 GIT binary patch literal 3329 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 LNkvXXu0mjf4`n&o literal 0 HcmV?d00001 diff --git a/skins/common/images/remove.png b/skins/common/images/remove.png new file mode 100755 index 0000000000000000000000000000000000000000..0cbf7d730e9f6bfd15a68f787648d1e74bbf3981 GIT binary patch literal 3346 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